Introduction (2024)

First steps

The engine has now been in development for a few years. The initial commit was made on May 4th 2022, so more than two years ago already. The idea of the project at the time was to familiarize myself with a few technologies I never tried, or wanted to improve with.

The main goals were to:

  1. Learn some rendering. So I decided to try OpenGL, in C++ for convenience because of my experience with the language and because OpenGL is way simpler than Vulkan (and more cross-platform than DirectX).
  2. Write some Rust. To do so I wanted to write a basic physics engine. The idea was to have the debug draw of the physics in OpenGL.
  3. Learn more about build systems. I already used Scons in the past, but otherwise knew very little about those. I wanted to try CMake.
  4. I wanted to improve my skills with C#, so I decided to have some sort of editor in C# in which I could dock the C++ window. The C# could provide some buttons to change the behavior of the simulation, and who knows, add some external logic in the future.

So this is what I did. I started working on this, first adding some math functions in Rust, then adding the most basic of OpenGL code. Just after that, I added a basic WPF editor. Not long after, I had a working basic CMake environment. At this point, on June 6th 2022, I had the basis for what I wanted to do and could expand on it.

First version

An important goal for me was to minimize the dependencies on libraries (not languages), and have fun doing everything, or as much as possible by myself. It is a good way to learn too.

The list of items I allowed myself to use were the following:

GLFW and GLAD are the bare minimum to include OpenGL in a project. ImGUI was added in the beginning to help with debugging runtime engine. It might be removed in the future to respect the true philosophy of this engine. On the other hand, I allowed myself to use the .NET libraries on the editor side in C#, including WPF for the editor window handling.

This had the advantage of making me learn how to use GIT submodules, since GLWF and ImGUI were integrated as GIT submodules. Also, GLAD had CMake files, so I learn to integrate CMake with other CMake projects while building. Note that the GLAD code is downloaded via Python code (using Pip). Here is the first editor version, with a compute shader.

First editor version (with compute shader)

I wanted everything to work easily for a new user, so everything is handled properly through CMake, as long as the user has the proper compilers installed on his machine. A user syncing the repository for the first time just has to launch a single batch file and all the (runtime) dependencies are downloaded and compiled.

In the future, I plan on adding documentation on the different compiler and tool dependencies, such as CMake itself, MSVC and the like. I might add a script to install it all automatically if needed.

At this point in time, I took a hiatus of several months and did not touch the project for a while.

Build Pipeline

In march of 2023 (the 15th to be specific), I went back to the project. In my mind, the first step to a build pipeline was to make it cross-platform. I then planned on compiling on Linux. The editor, being in C# and very tightly tied to Windows, was not added. A standalone target was created and this builds on both Windows and Linux.

I also added the support for INI files in order to set some configurations in a single place for the engine to read and interpret. The parser is custom, and I can set some paths in there.

First Linux version
First Linux version

Afterwards, I took another pause of several months before going back to the project in August 2023. This time, the goal was to have the build machines (i.e. the Bitbucket Pipelines) build the game (and run tests) on Linux after submits.

I created a Docker image to have a custom image fitted to my needs. Then I pushed the image on Docker and started using it in the Pipelines.

Docker Images
Docker Images

I won't detail all the steps I did, but not long ago I achieve a satisfying state for the pipelines. Depending on the branch different pipelines run.

Here is an example of the Main branch pipeline:

Main branch pipeline

Unit tests

In other pipelines, unit tests are run. The main unit tests internal function is just a very simple function where a test runner can be assigned functions to test and then it runs them.

EXPORT bool CALL UnitTests_RunAllTests(const bool warning_are_errors)
{
  SolidSnow::UnitTest::TestRunner test_runner;

  test_runner.Register(SolidSnow::UnitTest::Test_SOA_Functionalities);
  test_runner.Register(SolidSnow::UnitTest::Test_ThreadingTools_Functionalities);
  test_runner.Register(SolidSnow::UnitTest::Test_RuntimeTools);

#if SOLIDSNOW_WITH_ISPC
  test_runner.Register(SolidSnow::UnitTest::Test_ISPC);
#endif

  return test_runner.Run(warning_are_errors);
}
Basic unit test main function

This function is exposed as a parameter to the executable of the engine. This can be used by the pipeline to run unit tests in general, as well as builind the code.

# run unit tests
SolidSnow_Engine_Exe.exe -run_unit_tests
Option in the engine executable to run unit tests

Video Games Physics Course

In September 2023, I taught a class in Video Game Physics. I saw it as an opportunity to improve on my engine by doing all my live demonstrations in my custom engine. Since the students had to work in Unity (C#) the solutions were not usable as-is, but the logic and algorithms were.

Having a real camera

Before heading in a teaching class involving 3D objects, a mandatory change was for a real camera. For this, since I removed the dependency on GLM, a maths library, I needed to rewrite real matrix maths. The following is an example of suffering:

// specific to transform, this determinant is not general for matrices
MatrixTransform MatrixTransform::Adjugate() const
{
  //https://semath.info/src/inverse-cofactor-ex4.html

  MatrixTransform out;
  const MatrixTransform& m = *this;

  out(0, 0) = m(1, 1) * m(2, 2) - m(1, 2) * m(2, 1);
  out(0, 1) = m(0, 2) * m(2, 1) - m(0, 1) * m(2, 2);
  out(0, 2) = m(0, 1) * m(1, 2) - m(0, 2) * m(1, 1);
  out(1, 0) = m(1, 2) * m(2, 0) - m(1, 0) * m(2, 2);
  out(1, 1) = m(0, 0) * m(2, 2) - m(0, 2) * m(2, 0);
  out(1, 2) = m(0, 2) * m(1, 0) - m(0, 0) * m(1, 2);
  out(2, 0) = m(1, 0) * m(2, 1) - m(1, 1) * m(2, 0);
  out(2, 1) = m(0, 1) * m(2, 0) - m(0, 0) * m(2, 1);
  out(2, 2) = m(0, 0) * m(1, 1) - m(0, 1) * m(1, 0);

  out(3, 0) = m(3, 0) * m(2, 1) * m(1, 2)
            + m(2, 0) * m(1, 1) * m(3, 2)
            + m(1, 0) * m(3, 1) * m(2, 2)
            - m(1, 0) * m(2, 1) * m(3, 2)
            - m(2, 0) * m(3, 1) * m(1, 2)
            - m(3, 0) * m(1, 1) * m(2, 2);

  out(3, 1) = m(0, 0) * m(2, 1) * m(3, 2)
            + m(2, 0) * m(3, 1) * m(0, 2)
            + m(3, 0) * m(0, 1) * m(2, 2)
            - m(3, 0) * m(2, 1) * m(0, 2)
            - m(2, 0) * m(0, 1) * m(3, 2)
            - m(0, 0) * m(3, 1) * m(2, 2);

  out(3, 2) = m(3, 0) * m(1, 1) * m(0, 2)
            + m(1, 0) * m(0, 1) * m(3, 2)
            + m(0, 0) * m(3, 1) * m(1, 2)
            - m(0, 0) * m(1, 1) * m(3, 2)
            - m(1, 0) * m(3, 1) * m(0, 2)
            - m(3, 0) * m(0, 1) * m(1, 2);

  return out;
}
Matrix code sample

Once the mathematics were cleaned up, I could add code to support real cameras, and also different types of camera. I even added an option to switch camera at runtime. Camera code is rather simple on the user side now:

// client code sample for the camera manager
CameraManager& cameraManager = CameraManager::GetInstance();
Camera& camera = cameraManager.GetActiveCamera();
Client camera code

Improving renderer

The renderer was lacking on every aspect too. I added functionality to have different shaders run at the same time, and improve the control for the user.

Example with single color (with segment), texture and animated texture.

For example, I added a function to execute code on all shaders, or on a given shader, based on a shader key that is provided when creating a shader.

// Draw all the shaders
ForEachShader([](const auto& shader)
{
  shader.Draw();
})
Example of the renderer executing code on all shaders.
// Update the mesh of a specific shader
Renderer& renderer = Renderer::GetInstance();
renderer.ExecuteOnShader(m_shaderKey, [this](auto& shader)
{
  shader.UpdateMesh(m_mesh);
});
	
Example of executing code on a given shader.

Add basic texture support

Outside of better control on the shaders, I had to develop the different shaders. After I had single colors one done, I wanted to add texture support.

The problem before I could even draw textures is that I had to be able to import them. So the first thing I did was adding a picture importer in C. Why not C++ ? Because I decided it would be more fun to just do it in C. Given a path, now it is possible to import a bitmap (bmp) file. I used this page to write the code : Bitmap format. So if the picture I want to import is not a bitmap, I have to convert it in another software and then import it.

std::string path = "Actual/Path";
u32 widthOut = 0u;
u32 heightOut = 0u;
u8* data = SolidSnowPictureImporter.Import(path.c_str(), &widthOut, &heightOut);
Usage of the C picture importer from C++.

The result of this import is having a floor and walls in my little hardcoded demo map. Note that I also added the support for compute shaders to have animated textures (see the balls thrown by the player in the following picture).

Basic texture example (with animated texture too)

Add easy to use debug tools

One more thing I added after I had some semblance of control over my rendered was to add easy to use debug functions for a user. It uses my debug draw shader, which draws with a single color all the vertices, but also draws the edges of the mesh with a black outline.

#if SOLIDSNOW_DEBUG
void DebugDrawLine(const Segment& segment, const Vector3f& color);
void DebugDrawCircle(const Vector3f& position, const float radius, const Color& color, const uint32 segment);
void DebugDrawDisk(const Vector3f& position, const float radius, const Color& color, const uint32 segment);
void DebugDrawSquare(const Vector3f& position, const float halfLength, const Color& color);
void DebugDrawSphere(const Vector3f& position, const float radius, const Color& color);
#endif
Easy to use debug draw interface.

Actual physics

Finally I had some basis to work with, and I could do some physics things! In fact, they were done along the way, so they did not come after, it's just easier to blog about it this way. I added simple collision detection, for example Sphere-Plane collisions, and I can shoot the spheres by pressing a key. The gravity is also applied on the moving bodies.

Simple dynamics and collisions.

A particle manager was also added with support for many different types of forces in between them. The simulation was done in 2D for the particles.

There were two difficulties. First was the need for color support per triangle since I wanted to give a single color to each particle, which was a triangle, not just a debug draw with a color for all the mesh. And also the rendering of that many split triangles. I just put them in the same mesh. When new triangles are generated they are appended in an efficient way to the mesh. It could be used for 2D particles in the future but it is more a proof of concept.

2D particle simulation using a single mesh

After all this was done, I decided to add support for spatial partitions. I added grid support, and then I added QuadTree and Binary Space Partition code. The grid is a bit more trivial than the rest so I do not add any visuals for them here. The QuadTree could be converted into a OctTree, but I did not do it. I can still use the QuadTree in a 3D map in a game with little verticality (using top-view).

QuadTree support

After doing the QuadTree, I added Binary Space Partition. Instead of just aligning with the axis X and Y, I chose an angle to minimize the overlap with shapes. Afterwards, I added a varying number of subdivisions, starting from the division that was just made. The goal is to have the same number of elements in each subdivision, with a minimal number of overlaps with the spheres.

I also did that only in 2D, and it can be used in a 3D game. To be honest, splitting the world using planes might prove more complex. If I were to do it, I would probably just go with planes aligned with the X, Y and Z axis.

Binary space partition support

Another thing I did was to add some more complex shape collisions, but no real mesh collision complexity level. I just added rectangular prism support with any 3D rotation. I show in the next video how I calculate the closest point between the two shapes to know if they collide or not (the red spheres). Also, it proves the debug code works.

Debugging more complex shape collisions

Additional features

Eventually, I incorporated some more features I toyed with, but do not use yet within the engine.

Threading tools

I added some threading tools, and even added them to unit tests for performance testing. I added four main tools:

MyFunction(std::vector vector)
{
  // avoid concurrency issues
  std::vector vector_copy = vector;

  // easy initialization of a thread pool
  Threads::ThreadPool thread_pool;

  // launch a bunch of tasks using the add_task method
  {
    constexpr u32 k_job_count = 120u;
    const u32 per_job = Internal::k_item_count / k_job_count;
    static_assert(Internal::k_item_count % k_job_count == 0u);

    for (u32 i = 0u; i < k_job_count; i++)
    {
      ContainerView view{ vector, per_job * i, per_job };
      thread_pool.add_task([view = std::move(view)]()
      {
        // do stuff
      });
    }
  }

  // use the thread pool parallel for, it will have precedence over unstarded tasks
  // already requested
  {
    constexpr u32 k_job_count = 30u;
    const u32 per_job = Internal::k_item_count / k_job_count;
    static_assert(Internal::k_item_count % k_job_count == 0u);

    std::vector> vector_ranges;
    for (u32 i = 0u; i < k_job_count; i++)
    {
      vector_ranges.push_back({ vector_copy, i * per_job, per_job });
    }

    thread_pool.parallel_for(vector_ranges, [](ContainerView view)
    {
      // do stuff
    });
  }
}
Launching tasks and parallel for using the thread pool tool.

These tools are very useful when trying to occupy many of the cores. For example, with dummy data I achieved the following usage of my cores. It is imperfect, but can help a lot with heavier computations when needed. It can also help to have some background thread doing async operations, like loading assets.

Core usage example

SOA support

One more thing I added that can prove useful in case I decide to do more intensive calculations is to provide a tool to set the data easily in a cache friendly way. I added SOA (Structure of Array) support. A structure called StructureOfArray allows to create structures that have their data separated by members in memory. In the following example, there would be 2 different vectors of Vector3f, of same length, one holding the positions and the other the velocities. It can be appended by giving it both a position and velocity that will be added to each container. This interface is similar to the one we would have with a traditional array of structures.

enum class Dynamic
{
  Position,
  Velocity
};

using DynamicSOA = StructureOfArray<Dynamic, Vector3f, Vector3f>;

DynamicSOA dynamics;
dynamics.push_back({1.0f, 1.0f, 1.0f}, {0.0f, 0.0f, 0.0f});
Basic example of SOA usage

Once a structure of array is created, it is possible to get access to a specific subcontainer (for layered SOA, it can be a lower level SOA). For example, a function doing an operation only on positions could be passed a vector of positions that we accessed using the Get method.

std::vector<Vector3f>& position = dynamics.Get<Dynamic::Position>();
Querying a single data container.

The structures of arrays can be embedded in another. The result will always be flat data. In the following example, we create a structure of array having the DynamicSOA structure and a floating point (radius). The result will be 3 vectors, one of positions, one of velocities and one of radii. The container can pushed back like before, or resized like in the example.

enum class DynamicSphere
{
  Dynamics,
  Radius
};

using SOADynamicSphere = StructureOfArray<DynamicSphere, DynamicsSOA, f32>;
SOADynamicSphere sphereSOA;
sphereSOA.resize(12);
Embedding a SOA into another.

To help with parallelizing tools, we added view. We can parallelize by doing operations on different containers in the SOA, but I also added views to help. The views are basically spans on each vectors under the hood wrapped into a SOA view type for convenience. In the following example, the view starts at index 3 and is of length 3.

const SOADynamicSphere::ViewType spheresView(sphereSOA, 3, 3);
SOA views

Other tools

Along the way, I added some minor convenience tools. I added for example a very simple timer that just needs to be created, and then queried for elapsed time. By default it returns time in microseconds but can return it in milliseconds, nanoseconds (and others). It can also be reset.

One other useful thing I added was a pool allocator to avoid memory reallocations all the time. I used it for the particles simulation to avoid multiple complicated allocations. It is basically just a buffer with fixed size chunks, and then when an item is removed it is marked as freed. There is a cursor keeping track of the first free item to try to make it quicker to find a valid memory spot next time.

Adding Languages

To conclude on this introduction, I want to address the fact that I used several different languages. It is highly unrecommended to use this many in real life situations, but since I just wanted to have fun, I decided to add many languages, just to learn them, or just for the sake of it.

Original idea

The original idea was to only support Rust for physics, have OpenGL in C++, and the editor in C#.

Original trinity

To help build the C++ and its dependencies I decided to user CMake, and it was also one goal of the project: to get a bit more accustomed with CMake.

Build system

Build system

After trying to have a build system that works, does automated tests, cross compiles, builds on Bitbucket pipelines, I ended up using several more languages. The only programming language is Python. It was added to help with some file management after the build was finished. It will also help with external tools I plan on building, like a viewer for performance files I want the engine to output.

To launch CMake properly, I use Powershell and Bash, depending on the operating system. I also have one YAML files for the Bitbucket pipelines. Eventually I could have the equivalent for GitHub if I plan on having the repo on GitHub.

Scripting the Build System

Additional languages

Since the goal of the project was to have fun, I decided to add a bunch of different languages for different reasons. I use C files for the picture importers. For the moment it's only a bitmap importer, but it will grow. I have a lot of fun writing C code and this is why I use it here.

The tools, like external tools will be in Python if external and standalone or in C#, as part of the editor. Since I needed some tools that can run during the engine execution, I also added Zig for what I decided to call runtime tools. For example, code to read a binary scene. I decided to shield those tool of the engine and have them in Zig. Zig is a wonderful language for this kind of things and I enjoy it a lot where it is.

One more language support that was added was ISPC. It is to be like in Unreal engine, just there, able to be compiled alongside the C++ code to improve performance where it is needed. The only annoying thing with it is that it cannot be compiled from Linux to Windows (Linux->Linux, Windows->Windows and Windows->Linux are all fine). This is very annoying because my build system online is in Linux and thus the resulting executable cannot support ISPC on Windows.

Performance trinity

In the build system, as mentioned, XML is used, and for internal data support for the engine, XML is too. We also use INI files, and maybe a few other things, but for now, we do not use JSON for example.

Markup languages

Fluid simulator

One more feature I wanted to try for a long time was fluid simulation. I decided it was standalone enough, and needed enough math to be a good project to do in Fortran. So I added basic support for Fortran, but it is not hooked in the build system as of now. It does compile though. Since it is hard to debug, I decided to write the prototype in Julia. Julia is not part of the engine, but it should still be compiled after each submit.

Fluid pair

That's all, maybe?

As a bonus, let's just say we plan to support a Lua interperter someday for in-game scripting. This would be a good way to learn how to design interpreters, for a simple language, but it would also add the need for some Lua scripts.

Why not add one more ?

I think it will be all. I love making a mess with all the languages, but at some point it gets hard to maintain for such a small codebase (less than 20 000 lines at this point).